/*
 * Copyright (C) 2012 Facebook, Inc.
 *
 * Licensed under the Apache License, Version 2.0 (the "License");
 * you may not use this file except in compliance with the License.
 * You may obtain a copy of the License at
 *
 * http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package com.facebook.util;

import com.fasterxml.jackson.annotation.JsonCreator;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.joda.time.DateTime;
import org.joda.time.field.FieldUtils;

/**
 * Represents Time intervals either as durations or periods and abstracts out operations on time
 * intervals in the System.
 *
 * <p>Durations represent a fixed period of time regardless of when they start or end. ie. 1 day
 * will always be 86400 seconds. Instances that represent duration are constructed via {@link
 * #withMillis(long)}.
 *
 * <p>Periods represent a period of time but the actual time will depend on when the period starts.
 * For example 1 day will be 23 hours on the first day of DST transition and will be 25 hours on the
 * last day of DST transition. Instances that represent periods are constructed via {@link
 * #withTypeAndLength(TimeIntervalType, int)}.
 *
 * <p>The main operations abstracted out are the computation of start of an interval and addition /
 * subtraction of the interval from a time instant.
 */
public class TimeInterval {

  /**
   * An infinite time interval has a length of 0 and always returns the interval start as the start
   * of unix time epoch.
   */
  public static final TimeInterval INFINITE = new TimeInterval(null, 0);

  public static final TimeInterval ZERO = new TimeInterval(null, -1);
  private final long length;
  private final TimeIntervalType type;

  private TimeInterval(TimeIntervalType type, long length) {
    this.type = type;
    this.length = length;
  }

  /**
   * Creates a time interval having a fixed duration of time.
   *
   * @param millis the duration for the interval in milliseconds
   * @return the time interval instance.
   * @throws IllegalArgumentException if millis is less than 1.
   */
  public static TimeInterval withMillis(long millis) {
    validateLength(millis);
    return new TimeInterval(null, millis);
  }

  /**
   * Creates a time interval period based on the supplied type. The actual duration of the period
   * will vary depending on the time instant. The period will take into account DST, varying number
   * of days in a month, leap years, etc.
   *
   * <p>Note that if the interval length doesn't divide the maximum value of the interval type
   * equally, the last interval will be of a smaller length than the previous ones. For example if
   * you specify the interval as 40 seconds, the first interval will have the first 40 seconds in a
   * minute and the second interval will have the remaining 20 seconds in the minute.
   *
   * @param type the time interval type, cannot be null.
   * @param length the length of the interval
   * @return the time interval instance.
   * @throws IllegalArgumentException if length is less than 1.
   */
  public static TimeInterval withTypeAndLength(TimeIntervalType type, int length) {
    if (type == null) {
      throw new IllegalArgumentException("type cannot be null");
    }
    validateLength(length);
    return new TimeInterval(type, length);
  }

  /** Used by jackson for serde */
  @JsonCreator
  private static TimeInterval fromJson(
      @JsonProperty("type") TimeIntervalType type, @JsonProperty("length") long length) {
    if (type == null) {
      if (length == 0) {
        return INFINITE;
      } else if (length == -1) {
        return ZERO;
      }
    }
    validateLength(length);
    return new TimeInterval(type, length);
  }

  /**
   * Gets the start instant of the time interval that will contain the supplied time instant. Note
   * that the time zone of the supplied instant plays a significant role in computation of the
   * interval.
   *
   * @param instant the time instant
   * @return the start instant of the time interval that will contain the instant in the time zone
   *     of the supplied instant. If the TimeInterval is INFINITE unix epoch for the timezone is
   *     returned.
   */
  public DateTime getIntervalStart(DateTime instant) {
    // special handling for ZERO and INFINITE
    if (this == ZERO) {
      return instant;
    } else if (this == INFINITE) {
      return new DateTime(1970, 1, 1, 0, 0, 0, 0, instant.getZone());
    }

    if (type == null) {
      // unix epoch for the timezone.
      DateTime startOfTime = new DateTime(1970, 1, 1, 0, 0, 0, 0, instant.getZone());
      long intervalStart = ((instant.getMillis() - startOfTime.getMillis()) / length) * length;
      return startOfTime.plus(intervalStart);
    } else {
      return type.getTimeIntervalStart(instant, length);
    }
  }

  /**
   * Adds supplied multiples of this interval to the supplied instant.
   *
   * @param instant the instant that needs to be added to.
   * @param multiple the multiple value
   * @throws IllegalArgumentException if multiple is less than one.
   * @throws UnsupportedOperationException if the function is invoked on an {@link #INFINITE} object
   */
  public DateTime plus(DateTime instant, int multiple) {
    if (this == INFINITE) {
      throw new IllegalStateException(
          "plus() function is not supported on an infinite TimeInterval");
    } else if (this == ZERO) {
      return instant;
    }

    validateMultiple(multiple);

    if (type == null) {
      return instant.plus(multiple * getLength());
    } else {
      return instant.plus(type.toPeriod(FieldUtils.safeMultiplyToInt(multiple, getLength())));
    }
  }

  /**
   * Subtracts the supplied multiples of this interval from the supplied instant. If the
   * TimeInterval is {@link #INFINITE} the epoch in the timezone of {@code instant} is returned
   *
   * @param instant the instant to subtract from
   * @param multiple the multiple value
   * @throws IllegalArgumentException if multiple is less than one.
   */
  public DateTime minus(DateTime instant, int multiple) {
    if (this == INFINITE) {
      throw new IllegalStateException(
          "minus() function is not supported on an infinite TimeInterval");
    } else if (this == ZERO) {
      return instant;
    }

    validateMultiple(multiple);

    if (type == null) {
      return instant.minus(multiple * getLength());
    } else {
      return instant.minus(type.toPeriod(FieldUtils.safeMultiplyToInt(multiple, getLength())));
    }
  }

  /**
   * If this interval is of type period. Note that for {@link #INFINITE} & {@link #ZERO} time
   * intervals, this method will return false.
   */
  public boolean isPeriod() {
    return type != null;
  }

  /**
   * Returns the length value.
   *
   * @return the length value
   */
  @JsonProperty("length")
  public long getLength() {
    return length;
  }

  /**
   * Returns the interval type. Interval type is null if {@link #isPeriod()} is false.
   *
   * @return the interval type
   */
  @JsonProperty("type")
  public TimeIntervalType getType() {
    return type;
  }

  /**
   * Returns the length of the interval in milliseconds.
   *
   * <p>Note that the length is approximate if the interval was constructed via {@link
   * #withTypeAndLength(TimeIntervalType, int)}.
   *
   * <p>Also note that this method returns zero if the TimeInterval is {@link #INFINITE}, -1 if the
   * TimeInterval is {@link #ZERO}.
   *
   * @return the length in millis
   * @deprecated Usage of this method is not encouraged because this only works if the TimeInterval
   *     represents a duration. If the time interval is period, this might return unexpected values.
   */
  @Deprecated
  public long toApproxMillis() {
    if (type == null) {
      return length;
    } else {
      return type.toDurationMillis() * length;
    }
  }

  @Override
  public boolean equals(Object o) {
    if (this == o) {
      return true;
    }
    if (o == null || getClass() != o.getClass()) {
      return false;
    }

    TimeInterval that = (TimeInterval) o;

    if (length != that.length) {
      return false;
    }
    return type == that.type;
  }

  @Override
  public int hashCode() {
    int result = (int) (length ^ (length >>> 32));
    result = 31 * result + (type != null ? type.hashCode() : 0);
    return result;
  }

  @Override
  public String toString() {
    return "TimeInterval{" + "length=" + length + ", type=" + type + '}';
  }

  private static void validateMultiple(int multiple) {
    if (multiple < 0) {
      throw new IllegalArgumentException("Multiple cannot be less that 0 : " + multiple);
    }
  }

  private static void validateLength(long length) {
    if (length < 1) {
      throw new IllegalArgumentException("length cannot be less than one: " + length);
    }
  }
}
